fix(mobile): refetch open threads for live replies on the websocket path - #3243
fix(mobile): refetch open threads for live replies on the websocket path#3243ss251 wants to merge 1 commit into
Conversation
Thread replies arriving live were only picked up when the channel-window relay extension was in use. `threadRepliesProvider` is a one-shot future, so an open thread only sees a new reply when it is invalidated, and that invalidation lived inside `_mergeWindowEventIntoStore` — reachable only from the `_usingChannelWindow` branch of `_handleLiveEvent`. When `POST /query` fails once, `_fetchNewestHistory` latches the channel onto the websocket history path for the rest of the session. The reply still arrives over the live subscription and merges into the flat timeline, but the open thread view is never told, so it stays stale until an app restart rebuilds the future. Lift the invalidation into `_invalidateThreadReplies` and call it from `_handleLiveEvent` ahead of the branch, so both history paths refetch. The window path is unchanged: `_mergeWindowEventIntoStore` has a single caller, `_handleWindowLiveEvent`, which calls it as its unconditional first statement and itself has a single caller. The invalidation ran under the same two gates it runs under now, so every event that reached it before still reaches it now. Fixes block#3046 Signed-off-by: ss251 <ss251@uw.edu>
|
Read through this against The description frames the latch as "a single failed
final boundsEvents = events
.where((event) => event.kind == EventKind.channelWindowBounds)
.toList();
if (boundsEvents.length != 1) {
throw Exception(
'Channel window response must contain exactly one bounds event.',
);
}That 39006 overlay is emitted only by the bridge's The consequence is that on such a relay the fallback is not an intermittent This is worth separating from the scope note already in the description. Under a This also produces a distinctive and, I think, useful triage signature, because the
So a fresh inbox alongside a stale channel/thread is positive evidence the When device logs are available, the direct confirmation is the existing debug line: Two suggestions, both optional and neither blocking this PR:
Neither changes the fix here, which is correct as written: moving the invalidation |
## Summary - refetch mounted mobile thread replies after relay reconnect, preserving the previous reply list during recovery - auto-dispose route-scoped relay reply caches so reopening a thread queries current relay state - invalidate live replies through both the channel-window and legacy websocket-history paths - preserve optimistic-reply confirmation when the route closes before its deferred cleanup - stabilize rapid same-second messages using desktop's existing split contract: channel timelines render `(created_at ASC, id DESC)` while threads render `(created_at ASC, id ASC)` - retain late live rows after a channel window is exhausted instead of dropping same-second tail messages Closes #4404. Closes #4830. Closes #6204. ## Context The broad all-channel/all-DM stale-session defect reported in #4402 is already addressed on current `main` by #4372 and #3053. Two distinct mobile gaps remained: 1. `threadRepliesProvider` was a process-lifetime one-shot query, so replies missed while the socket was stale remained absent after reconnect or after closing and reopening the thread. 2. Mobile had inconsistent timestamp-only and event-id ordering across channel producers. Rapid messages routinely share Nostr's one-second timestamp, so later hydration/live reconciliation could reshuffle them. Desktop deliberately has two render contracts: channel windows reverse the relay's composite order to `(created_at ASC, id DESC)`, while thread replies use `(created_at ASC, id ASC)`. This consolidates the current-main portions of #4831 and #3243 rather than reviving stale overlapping branches. ## Validation Exact pushed head: `be92d9542c6cd1342733bdc5e8359664b511ce02` - focused channel-provider/window/thread suites: 48/48 passed - incident regression: a mounted thread misses a reply while disconnected, reconnects, and renders the recovered reply - route regression: closing and reopening a thread performs a fresh authoritative query - websocket fallback regression: live reply invalidates the mounted thread even without the channel-window path - disposal regression: optimistic confirmation survives provider disposal between rebuild and deferred cleanup - ordering regressions: channel window/live, websocket fallback, optimistic sends, deep links, both pagination paths, and thread merges preserve their desktop-compatible same-second order - boundary regression: exhausted windows admit late same-second live rows without weakening open-page cursor boundaries - independent adversarial review: no production blocker; source contract verified across all producers and relay cursor semantics unchanged - pre-push Mobile lane passed at exact head, including analysis, file-size/branch checks, and full Flutter suite: 1,675/1,675 passed - `git diff --check` --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz>
Summary
Live thread replies never reached an open thread view on mobile's websocket history path.
threadRepliesProvideris a one-shotFutureProvider.family— an open thread only picks up a new reply when something invalidates it. That invalidation existed and was already correct, but it lived inside_mergeWindowEventIntoStore, which is reachable only from the_usingChannelWindow == truebranch of_handleLiveEvent. The websocket fallback branch could never see it: the reply merged into the flat channel timeline and the open thread was never told._fetchNewestHistorylatches that branch for the rest of the session — it catches any error from the channel-window query, logschannel window unavailable … falling back to WS history, and sets_usingChannelWindow = false. So a single failedPOST /queryis enough to put a session on the path where an open thread stops updating until the app is restarted and the one-shot future re-runs.This moves the invalidation into
_invalidateThreadReplies(event)and calls it from_handleLiveEventahead of the branch, so both history paths refetch.The channel-window path is unchanged.
_mergeWindowEventIntoStorehas exactly one caller,_handleWindowLiveEvent, which calls it as its unconditional first statement;_handleWindowLiveEventin turn has exactly one caller — the_usingChannelWindow == truebranch of_handleLiveEvent. The invalidation ran under the same two gates it runs under now (channelTimelineContentKinds,parentId != null), so every event that reached the old invalidation reaches the new one. The only behavioral delta is the fallback branch gaining it.Scope, stated plainly:
threadRepliesProviderqueries the samePOST /queryendpoint as the channel window, so this fixes the transient case — the endpoint blips, the session latches onto the fallback, the endpoint recovers, and live thread replies keep working instead of requiring a restart. Under a persistent/queryoutage the thread view cannot load replies with or without this patch.Related issue
Fixes #3046.
Duplicate search — the closest open work in this file neighbourhood, all distinct from this change:
wss://to plaintextws://), with PRs fix(mobile): keep TLS on relays joined by invite #3139 and fix(mobile): invite join stuck on Connecting — URL scheme mismatch #2726. Genuinely adjacent: aws-schemebaseUrlalso makesPOST /querythrow, which latches this same fallback. But it is a different defect with a different fix, and neither PR toucheschannel_messages_provider.dartorthread_replies_provider.dart.relay_session.dart,channels_provider.dart) and Polish mobile message and search layouts #3121 (thread_detail_page.dart, layout and typography) — no file overlap.Searched by issue citation and by changed-file list across all 437 open PRs (
gh pr list --repo block/buzz --state open --limit 1000 --json number,files): none cite #3046, and none touch either file this change edits.Testing
Full mobile suite: 825 passed, 1 skipped.
flutter analyzeclean;dart format --output=none --set-exit-if-changed .clean (283 files, 0 changed).Added
websocket fallback refetches an open thread when a reply arrives livetomobile/test/features/channels/channel_messages_provider_test.dart. It fails the channel-window query to drive the notifier onto the fallback path, opens the thread, emits a live reply over the subscription, and asserts the thread refetches.Negative control — the same test with only the
lib/change reverted:An empty thread — the reported symptom. It passes with the change applied.
Not reproduced against a live relay. The mechanism was traced at source and is covered by the test above, but I did not observe the failure on a device against a real relay, so I cannot confirm which condition put the reporter on the fallback branch. The behavior is consistent with their follow-up that "closing and re-opening the app allows the threaded replies to be seen," since a restart re-runs the one-shot future. If there is a second path into #3046, I would want to know.
No UI change — no widget, layout, style, or string is touched — so there are no screenshots.